package io.kiki.stack.http.feign;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSocketFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import java.util.zip.InflaterInputStream;

import static io.kiki.stack.http.feign.Util.*;
import static java.lang.String.CASE_INSENSITIVE_ORDER;
import static java.lang.String.format;

public class DefaultClient implements Client {

    private final SSLSocketFactory sslContextFactory;
    private final HostnameVerifier hostnameVerifier;

    /**
     * Disable the request body internal buffering for {@code HttpURLConnection}.
     *
     * @see HttpURLConnection#setFixedLengthStreamingMode(int)
     * @see HttpURLConnection#setFixedLengthStreamingMode(long)
     * @see HttpURLConnection#setChunkedStreamingMode(int)
     */
    private final boolean disableRequestBuffering;

    /**
     * Create a new client, which disable request buffering by default.
     *
     * @param sslContextFactory SSLSocketFactory for secure https URL connections.
     * @param hostnameVerifier  the host name verifier.
     */
    public DefaultClient(SSLSocketFactory sslContextFactory, HostnameVerifier hostnameVerifier) {
        this.sslContextFactory = sslContextFactory;
        this.hostnameVerifier = hostnameVerifier;
        this.disableRequestBuffering = true;
    }

    /**
     * Create a new client.
     *
     * @param sslContextFactory       SSLSocketFactory for secure https URL connections.
     * @param hostnameVerifier        the host name verifier.
     * @param disableRequestBuffering Disable the request body internal buffering for {@code
     *                                HttpURLConnection}.
     */
    public DefaultClient(SSLSocketFactory sslContextFactory, HostnameVerifier hostnameVerifier, boolean disableRequestBuffering) {
        super();
        this.sslContextFactory = sslContextFactory;
        this.hostnameVerifier = hostnameVerifier;
        this.disableRequestBuffering = disableRequestBuffering;
    }

    @Override
    public Response execute(Request request, Request.Options options) throws IOException {
        HttpURLConnection httpURLConnection = convertAndSend(request, options);
        return convertResponse(httpURLConnection, request);
    }

    Response convertResponse(HttpURLConnection httpURLConnection, Request request) throws IOException {
        int status = httpURLConnection.getResponseCode();
        String reason = httpURLConnection.getResponseMessage();

        if (status < 0) {
            throw new IOException(format("Invalid status(%s) executing %s %s", status, httpURLConnection.getRequestMethod(), httpURLConnection.getURL()));
        }

        Map<String, Collection<String>> headers = new TreeMap<>(CASE_INSENSITIVE_ORDER);
        for (Map.Entry<String, List<String>> field : httpURLConnection.getHeaderFields().entrySet()) {
            // response message
            if (field.getKey() != null) {
                headers.put(field.getKey(), field.getValue());
            }
        }

        Integer length = httpURLConnection.getContentLength();
        if (length == -1) {
            length = null;
        }
        InputStream stream;
        if (status >= 400) {
            stream = httpURLConnection.getErrorStream();
        } else {
            stream = httpURLConnection.getInputStream();
        }
        if (stream != null && this.isGzip(headers.get(CONTENT_ENCODING))) {
            stream = new GZIPInputStream(stream);
        } else
            if (stream != null && this.isDeflate(headers.get(CONTENT_ENCODING))) {
                stream = new InflaterInputStream(stream);
            }
        return Response.builder().status(status).reason(reason).headers(headers).request(request).body(stream, length).build();
    }

    public HttpURLConnection getConnection(final URL url) throws IOException {
        return (HttpURLConnection) url.openConnection();
    }

    HttpURLConnection convertAndSend(Request request, Request.Options options) throws IOException {
        final URL url = new URL(request.url());
        final HttpURLConnection httpURLConnection = this.getConnection(url);
        if (httpURLConnection instanceof HttpsURLConnection sslCon) {
            if (sslContextFactory != null) {
                sslCon.setSSLSocketFactory(sslContextFactory);
            }
            if (hostnameVerifier != null) {
                sslCon.setHostnameVerifier(hostnameVerifier);
            }
        }
        httpURLConnection.setConnectTimeout(options.connectTimeoutMillis());
        httpURLConnection.setReadTimeout(options.readTimeoutMillis());
        httpURLConnection.setAllowUserInteraction(false);
        httpURLConnection.setInstanceFollowRedirects(options.isFollowRedirects());
        httpURLConnection.setRequestMethod(request.httpMethod().name());

        Collection<String> contentEncodingValues = request.headers().get(CONTENT_ENCODING);
        boolean gzipEncodedRequest = this.isGzip(contentEncodingValues);
        boolean deflateEncodedRequest = this.isDeflate(contentEncodingValues);

        boolean hasAcceptHeader = false;
        Integer contentLength = null;
        for (String field : request.headers().keySet()) {
            if (field.equalsIgnoreCase("Accept")) {
                hasAcceptHeader = true;
            }
            for (String value : request.headers().get(field)) {
                if (field.equals(CONTENT_LENGTH)) {
                    if (!gzipEncodedRequest && !deflateEncodedRequest) {
                        contentLength = Integer.valueOf(value);
                        httpURLConnection.addRequestProperty(field, value);
                    }
                }
                // Avoid add "Accept-encoding" twice or more when "compression" option is enabled
                else
                    if (field.equals(ACCEPT_ENCODING)) {
                        httpURLConnection.addRequestProperty(field, String.join(", ", request.headers().get(field)));
                        break;
                    } else {
                        httpURLConnection.addRequestProperty(field, value);
                    }
            }
        }
        // Some servers choke on the default accept string.
        if (!hasAcceptHeader) {
            httpURLConnection.addRequestProperty("Accept", "*/*");
        }

        byte[] body = request.body();

        if (body != null) {
            /*
             * Ignore disableRequestBuffering flag if the empty body was set, to ensure that internal
             * retry logic applies to such requests.
             */
            if (disableRequestBuffering) {
                if (contentLength != null) {
                    httpURLConnection.setFixedLengthStreamingMode(contentLength);
                } else {
                    httpURLConnection.setChunkedStreamingMode(8196);
                }
            }
            httpURLConnection.setDoOutput(true);
            OutputStream out = httpURLConnection.getOutputStream();
            if (gzipEncodedRequest) {
                out = new GZIPOutputStream(out);
            } else
                if (deflateEncodedRequest) {
                    out = new DeflaterOutputStream(out);
                }
            try {
                out.write(body);
            } finally {
                try {
                    out.close();
                } catch (IOException suppressed) { // NOPMD
                }
            }
        }

        if (body == null && request.httpMethod().isWithBody()) {
            // To use this Header, set 'sun.net.http.allowRestrictedHeaders' property true.
            httpURLConnection.addRequestProperty("Content-Length", "0");
        }

        return httpURLConnection;
    }

    private boolean isGzip(Collection<String> contentEncodingValues) {
        return contentEncodingValues != null && !contentEncodingValues.isEmpty() && contentEncodingValues.contains(ENCODING_GZIP);
    }

    private boolean isDeflate(Collection<String> contentEncodingValues) {
        return contentEncodingValues != null && !contentEncodingValues.isEmpty() && contentEncodingValues.contains(ENCODING_DEFLATE);
    }
}
